package test.com.sms.framework.excel.runner;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;

import java.lang.annotation.Annotation;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;


import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.ss.util.AreaReference;
import org.apache.poi.ss.util.CellReference;

import org.junit.Ignore;
import org.junit.Test;
import org.junit.internal.AssumptionViolatedException;
import org.junit.internal.runners.model.EachTestNotifier;
import org.junit.runner.Description;
import org.junit.runner.Runner;
import org.junit.runner.notification.RunNotifier;
import org.junit.runners.BlockJUnit4ClassRunner;
import org.junit.runners.ParentRunner;
import org.junit.runners.Suite;
import org.junit.runners.model.FrameworkMethod;
import org.junit.runners.model.InitializationError;
import org.junit.runners.model.Statement;

/**
 * ExcelParameterized is an extension to the standard JUnit runners that allows
 * tests to externalize the test data in excel spreadsheets for each method
 * under test. ExcelParameterized can be seen as a specialization of the {@code
 * Parameterized} runner in JUnit 4.x. This runner specifically tries to address
 * the following needs that are not addressed by the {@code Parameterized}
 * runner.
 * <ul>
 * <li>Externalize test data in excel spreadsheets
 * <li>Specify data sets at test method level
 * <li>Execute each test method only for the number of rows of test data
 * <li>Not force developers to write separate test class for tests that are not
 * parameterized
 * </ul>
 * A sample test case when implemented with ExcelParameterized would look like:
 *
 * <pre>
 * {@literal @RunWith(ExcelParameterized.class)}
 *  public AccountTest {
 *      public AccountTest() {
 *         // mandatory no args constructor
 *      }
 *
 *     {@literal @Test}
 *     {@literal @SpreadSheetData(filename = "Accounts.xls", sheetName = "AccountsTestData",
 *                      startingCell = "A2", endingCell = "D6",
 *                      isFirstColumnScenarioName = true)}
 *      public void testWithdraw(String accountName, double balance, double amount) {
 *          Account account = new Account(accountName, balance);
 *          account.withdraw(amount);
 *          assertThat("Balance is ", account.balance(), is(balance - amount));
 *          assertThat("Balance ", account.balance(), is(greaterThan(0.0)));
 *      }
 *
 *     {@literal @Test}
 *     {@literal @SpreadSheetData(filename = "Accounts.xls", sheetName = "AccountsTestData",
 *                                startingCell = "A10", endingCell = "C10")}
 *      public void testDeposit(String accountName, double balance, double amount) {
 *         Account account = new Account(accountName, balance);
 *         account.deposit(amount);
 *         assertThat(account.balance(), is(balance + amount));
 *      }
 *
 *     {@literal @Test(expected=Exception.class)}
 *      public void testDepositWithNegativeAmount() {
 *         Account account = new Account("10232", 100.32d);
 *         account.deposit(-29.34d);
 *      }
 *  }
 * </pre>
 *
 * Guidelines for creating tests with ExcelParameterized would be:
 * <ul>
 * <li>Annotate class to run with ExcelParameterized runner
 * <li>Test class should have exactly 1 constructor that takes no arguments
 * <li>Test methods that need to be parameterized should be annotated with
 * SpreadSheetData
 * <li>SpreadSheetData annotation should specify fully qualified filename of
 * excel file, name of the sheet, the starting and ending cells that form the
 * range for the data-set
 * <li>If the first column in the range has the test scenario name, the test
 * case would be named with that
 * <li>The test method should contain as many arguments as there are columns in
 * the data-set excluding the scenario name
 * <li>If the column is formatted as text the argument type should be String
 * <li>If the column is formatted as a number with a style of 0 or #,##0 the
 * argument type should be int
 * <li>If the column has numbers but is formatted as Text then a String
 * representation of the double value of the number will be returned
 * <li>If the column is formatted as a number with any other style then the
 * argument type should be double
 * <li>If the column has boolean value then argument should be a boolean
 * <li>If the argument type is String make sure the column is formatted as Text.
 * If a column is formatted as General, then a number in a text column would be
 * treated as double. This is a limitation of the Apache POI library
 * <li>If there are tests that don't need parameterization simply don't annotate
 * them with SpreadSheetData annotation
 * <li>Add Apache POI library to the classpath
 * </ul>
 * <image src="spreadsheet.jpg" alt="sample excel sheet"/> <image
 * src="ide_results.jpg" alt="Result from the IDE"/>
 *
 * @see org.apache.poi.ss.usermodel.Cell
 * @see org.junit.runners.Parameterized
 * @author Subbu Sailappan
 *
 */
public class ExcelParameterized extends ParentRunner<Runner> {
    private static final int EXCEL_FORMAT_0 = 1;
    private static final int EXCEL_FORMAT_NNN0 = 3;
    private static final int EXCEL_FORMAT_TEXT = 0x31;

    private final List<Runner> runners = new ArrayList<Runner>();

    public ExcelParameterized(final Class<?> klass) throws Throwable {
        super(klass);
        final List<FrameworkMethod> testMethods =
            getTestClass().getAnnotatedMethods(Test.class);
        for (FrameworkMethod eachTestMethod : testMethods) {
            runners.add(new ParameterizedTestMethodRunner(klass,
                                                          eachTestMethod));
        }
    }

    @Override
    protected List<Runner> getChildren() {
        return runners;
    }

    @Override
    protected Description describeChild(Runner child) {
        return child.getDescription();
    }

    @Override
    protected void runChild(Runner child, RunNotifier notifier) {
        child.run(notifier);
    }

    protected List<Object[]> getParametersList(FrameworkMethod fMethod) throws Throwable {
        SpreadSheetData spreadSheetData =
            fMethod.getMethod().getAnnotation(SpreadSheetData.class);
        if (spreadSheetData == null) {
            return Collections.emptyList();
        }
        File spreadSheet = new File(spreadSheetData.filename());
        String sheetName = spreadSheetData.sheetName();
        String startingCell = spreadSheetData.startingCell();
        String endingCell = spreadSheetData.endingCell();
        return getDataFromSpreadSheet(spreadSheet, sheetName, startingCell,
                                      endingCell);
    }

    private boolean isFirstColumnScenarioName(FrameworkMethod fMethod) throws Throwable {
        SpreadSheetData spreadSheetData =
            fMethod.getMethod().getAnnotation(SpreadSheetData.class);
        return spreadSheetData == null ? false :
               spreadSheetData.isFirstColumnScenarioName();
    }

    protected List<Object[]> getDataFromSpreadSheet(File spreadSheet,
                                                    String sheetName,
                                                    String startingCell,
                                                    String endingCell) throws Exception {
        List<Object[]> listData = null;
        try {
            Workbook workbook = getWorkbook(spreadSheet);
            Sheet sheet = workbook.getSheet(sheetName);
            Object[][] data =
                readDataFromSheet(startingCell, endingCell, sheet);
            listData = Arrays.asList(data);
        } catch (FileNotFoundException e) {
            throw new IllegalArgumentException("Spreadsheet doesnt exist at " +
                                               spreadSheet.getAbsolutePath());
        }
        return listData;
    }

    private Object[][] readDataFromSheet(String startingCell,
                                         String endingCell, Sheet sheet) {
        CellReference start = new CellReference(startingCell);
        CellReference end = new CellReference(endingCell);
        int startRow = start.getRow();
        int startCol = start.getCol();
        int numRows = end.getRow() - startRow + 1;
        int numCols = end.getCol() - startCol + 1;

        AreaReference area = new AreaReference(start, end);
        CellReference[] allCells = area.getAllReferencedCells();
        Object[][] data = new Object[numRows][numCols];
        for (CellReference cellRef : allCells) {
            Row row = sheet.getRow(cellRef.getRow());
            Cell cell = row.getCell(cellRef.getCol());
            data[cellRef.getRow() - startRow][cellRef.getCol() - startCol] =
                    getValue(cell);
        }
        return data;
    }

    private Workbook getWorkbook(File spreadSheet) throws IOException,
                                                          InvalidFormatException,
                                                          FileNotFoundException {
        InputStream inp;
        inp = new FileInputStream(spreadSheet);
        return WorkbookFactory.create(inp);
    }

    protected Object getValue(Cell cell) {
        if (cell == null) {
            return null;
        }
        switch (cell.getCellType()) {
        case Cell.CELL_TYPE_STRING:
            return cell.getStringCellValue();
        case Cell.CELL_TYPE_BOOLEAN:
            return cell.getBooleanCellValue();
        case Cell.CELL_TYPE_NUMERIC:
            return getValueFromNumericCell(cell);
        default:
            return null;
        }
    }

    protected Object getValueFromNumericCell(Cell cell) {
        if (DateUtil.isCellDateFormatted(cell)) {
            return cell.getDateCellValue();
        } else {
            switch (cell.getCellStyle().getDataFormat()) {
            case EXCEL_FORMAT_0:
            case EXCEL_FORMAT_NNN0:
                return new Double(cell.getNumericCellValue()).intValue();
            case EXCEL_FORMAT_TEXT:
                return String.valueOf(cell.getNumericCellValue());
            default:
                return cell.getNumericCellValue();
            }
        }
    }

    private class ParameterizedTestMethodRunner extends Suite {

        private final List<Runner> runners = new ArrayList<Runner>();

        ParameterizedTestMethodRunner(Class<?> klass,
                                      FrameworkMethod method) throws Throwable {
            super(klass, Collections.<Runner>emptyList());
            List<Object[]> dataSet = getParametersList(method);
            int numTestRuns = dataSet.size();
            if (dataSet.isEmpty() &&
                methodHasNoSpreadsheetAnnotation(method)) {
                numTestRuns = 1;
            }
            for (int i = 0; i < numTestRuns; i++) {
                runners.add(new TestClassRunnerForParameters(getTestClass().getJavaClass(),
                                                             method, dataSet,
                                                             i));
            }
        }

        @Override
        protected List<Runner> getChildren() {
            return runners;
        }

        private boolean methodHasNoSpreadsheetAnnotation(FrameworkMethod method) {
            return method.getAnnotation(SpreadSheetData.class) == null;
        }

        /**
         * Don't want BeforeClass and AfterClass to be executed for test method
         *
         * @param notifier
         * @return
         */
        @Override
        protected Statement classBlock(RunNotifier notifier) {
            return childrenInvoker(notifier);
        }
    }

    private class TestClassRunnerForParameters extends BlockJUnit4ClassRunner {
        private final int fParameterSetNumber;
        private final List<Object[]> fParameterList;
        private final FrameworkMethod method;
        private String scenarioName = "";

        TestClassRunnerForParameters(Class<?> type, FrameworkMethod method,
                                     List<Object[]> fParameterList,
                                     int runNumber) throws InitializationError,
                                                           Throwable {
            super(type);
            this.fParameterList = fParameterList;
            this.method = method;
            fParameterSetNumber = runNumber;
            this.scenarioName = computeScenarioName();
        }

        @Override
        public Object createTest() throws Exception {
            return getTestClass().getOnlyConstructor().newInstance();
        }

        private String computeScenarioName() throws Throwable {
            if (fParameterList.isEmpty()) { // no params
                return "";
            }
            if (isFirstColumnScenarioName(method)) {
                Object[] data = fParameterList.get(fParameterSetNumber);
                return (String)data[0];
            } else {
                return "";
            }
        }

        private Object[] computeParams() throws Throwable {
            if (fParameterList.isEmpty()) { // no params
                return new Object[0];
            }
            if (isFirstColumnScenarioName(method)) {
                Object[] data = fParameterList.get(fParameterSetNumber);
                Object[] dataWithoutScenarioName = new Object[data.length - 1];
                System.arraycopy(data, 1, dataWithoutScenarioName, 0,
                                 data.length - 1);
                StringBuilder sb = new StringBuilder();
                scenarioName = sb.append(data[0]).append(".").toString();
                return dataWithoutScenarioName;
            }
            return fParameterList.get(fParameterSetNumber);
        }

        @Override
        protected List<FrameworkMethod> getChildren() {
            return Arrays.asList(new FrameworkMethod[] { method });
        }

        @Override
        protected String getName() {
            return String.format("[%s]", fParameterSetNumber);
        }

        @Override
        protected String testName(final FrameworkMethod method) {
            return String.format("%s[%s]%s", method.getName(),
                                 fParameterSetNumber, scenarioName);
        }

        @Override
        protected Statement classBlock(RunNotifier notifier) {
            return childrenInvoker(notifier);
        }

        /**
         * overriding to invoke test methods with params Returns a
         * {@link Statement} that invokes {@code method} on {@code test}
         */
        @Override
        protected Statement methodInvoker(FrameworkMethod method,
                                          Object test) {
            try {
                return new InvokeMethodWithParams(method, test,
                                                  computeParams());
            } catch (Throwable e) {
                throw new IllegalStateException(e);
            }

        }

        /**
         * test methods should have args. overridden method forces no args test
         * methods
         *
         * @param errors
         *            track errors
         */
        @Override
        protected void validateTestMethods(List<Throwable> errors) {
            validatePublicVoidMethods(Test.class, false, errors);
        }

        /**
         * just check that test methods are public and void
         *
         * @param annotation
         *            annotation for test methods
         * @param isStatic
         *            is method static
         * @param errors
         *            track errors
         */
        protected void validatePublicVoidMethods(Class<? extends Annotation> annotation,
                                                 boolean isStatic,
                                                 List<Throwable> errors) {
            List<FrameworkMethod> methods =
                getTestClass().getAnnotatedMethods(annotation);

            for (FrameworkMethod eachTestMethod : methods) {
                eachTestMethod.validatePublicVoid(isStatic, errors);
            }
        }

        /**
         * overriding this method to give a better error message when method
         * args don't match spreadsheet columns
         *
         * @param method
         *            method under test
         * @param notifier
         *            notifier to report test events
         */
        @Override
        protected void runChild(FrameworkMethod method, RunNotifier notifier) {
            EachTestNotifier eachNotifier = makeNotifier(method, notifier);
            if (method.getAnnotation(Ignore.class) != null) {
                eachNotifier.fireTestIgnored();
                return;
            }

            eachNotifier.fireTestStarted();
            try {
                methodBlock(method).evaluate();
            } catch (IllegalArgumentException e) {
                String betterMsg;
                if (e.getMessage().contains("wrong number of arguments")) {
                    betterMsg =
                            "number of columns in spreadsheet should be same as method args";
                } else if (e.getMessage().contains("argument type mismatch")) {
                    betterMsg =
                            "types of argument in test method should match the data types of the spreadsheet columns";
                } else {
                    betterMsg = e.getMessage();
                }
                eachNotifier.addFailure(new Exception(betterMsg, e));
            } catch (AssumptionViolatedException e) {
                eachNotifier.addFailedAssumption(e);
            } catch (Throwable e) {
                eachNotifier.addFailure(e);
            } finally {
                eachNotifier.fireTestFinished();
            }
        }

        private EachTestNotifier makeNotifier(FrameworkMethod method,
                                              RunNotifier notifier) {
            Description description = describeChild(method);
            return new EachTestNotifier(notifier, description);
        }

    }

}
